Skip to content

Song API ​

This document is based on the following source files:

  • internal/handlers/music.go -- Song CRUD, playback stream, covers, lyrics, tag writing, file organization, and duplicate detection handlers
  • internal/handlers/hls.go -- HLS radio reverse proxy handler (playlist rewriting + segment passthrough)
  • internal/app/routers.go -- Song-related route registration
  • internal/models/models.go -- Song / BatchDeleteSongsRequest / BatchDeleteSongsResponse
  • internal/models/lyric.go -- LyricPayload
  • internal/services/song_service.go -- OrganizeItem / OrganizeResult / CleanResult

Table of Contents ​

  1. Overview
  2. List and Query
  3. Create, Update, Delete
  4. Lyrics and Tags
  5. File Organization
  6. Playback and Media
  7. HLS Reverse Proxy

1. Overview ​

Section source: internal/handlers/music.go, internal/app/routers.go

The song management module is Songloft's core API, covering song CRUD, playback stream distribution, covers/lyrics, audio tag writing, file organization, and duplicate detection. All endpoints require JWT authentication (BearerAuth), with the base path /api/v1.

Song types: local (local files), remote (external URL / plugin source), radio (online broadcast / live stream).

Song JSON serialization rules (handled uniformly by MarshalJSON):

  • url -- Unified to /api/v1/songs/{id}/play (HLS radio uses .../play.m3u8)
  • cover_url -- /api/v1/songs/{id}/cover when a cover exists, otherwise empty
  • lyric_url -- /api/v1/songs/{id}/lyric when lyrics exist, otherwise empty
  • source_url -- The original stream address, returned only for remote/radio types

Some read-only fields (returned with the Song object in details/lists):

  • is_live -- Whether it is a live stream (semantics apply only to the remote type)
  • is_video -- bool, whether it contains a real video track (probed by ffprobe during scanning, excluding cover-only images); the client uses this to render video / choose the casting MIME (introduced by the 0024 migration)

Error response format: {"error":"...", "detail":"..."} (detail is optional).


2. List and Query ​

Section source: internal/handlers/music.go, internal/database/filters.go

2.1 GET /songs -- Paginated List ​

Path: /api/v1/songs | Authentication: BearerAuth

Gets the song list, supporting filtering by type, keyword search, path-prefix filtering, and pagination.

Query parameters:

ParameterTypeRequiredDefaultDescription
typestringNo--local / remote / radio
keywordstringNo--Search keyword (matches title, artist, album)
path_prefixstringNo--Filter by file_path prefix
limitintNo20Items per page, upper limit 100000
offsetintNo0Offset

Success response (200): {"songs": [Song...], "total": 100, "limit": 20, "offset": 0}

Errors: 500 Failed to get song list / total


2.2 GET /songs/ids -- ID List ​

Path: /api/v1/songs/ids | Authentication: BearerAuth

Shares filter conditions with /songs (type, keyword, path_prefix), but returns only IDs. Used for batch operation scenarios such as "select all within the current filter range"; not paginated.

Success response (200): {"ids": [1, 2, 3], "total": 3}

Errors: 500 Failed to get song ID list


2.3 GET /songs/duplicates -- Duplicate Detection ​

Path: /api/v1/songs/duplicates | Authentication: BearerAuth

Queries groups of local songs with identical content via audio fingerprints (Chromaprint). Fingerprint computation must be completed first (POST /scan/fingerprints) — auto-computing fingerprints after a scan is off by default (see /settings/scan-auto-fingerprint). Only the first 120 seconds of each song are sampled, so songs within one fingerprint are further clustered by full-file length with a 30-second tolerance (adjacent comparison; unknown length never splits) to rule out "shared intro" collisions.

Success response (200):

json
{
  "groups": [
    {
      "fingerprint": "AQADtNIyR...",
      "songs": [
        {"id": 1, "title": "夜曲", "artist": "周杰伦", "album": "十一月的萧邦",
         "duration": 253.5, "file_path": "/music/夜曲.mp3", "format": "mp3",
         "bit_rate": 320, "file_size": 10485760, "cover_url": "/api/v1/songs/1/cover",
         "added_at": "2024-01-01T12:00:00Z"}
      ]
    }
  ],
  "total_groups": 1,
  "total_duplicates": 2
}

Errors: 500 Failed to query duplicate songs


2.4 GET /songs/facets -- Facet Aggregation ​

Path: /api/v1/songs/facets | Authentication: BearerAuth

Aggregates the library by a specified dimension, returning the non-empty values under that dimension, the song count for each, and the cover URL of one representative song, used for the card grid of "browse by category". After obtaining a value, use /songs?<field>=<value> to fetch songs under that category.

Query parameters:

ParameterTypeRequiredDefaultDescription
fieldstringYes--Aggregation dimension: genre / artist / album / language / style / year / decade (the value for year/decade is a numeric string; a decade such as "1990" means 1990-1999)
keywordstringNo--Fuzzy search over values
limitintNo20Page size, upper limit 100000
offsetintNo0Pagination offset
sortstringNocountSort dimension: count / name
orderstringNo--Sort direction: asc / desc (count defaults to desc, name defaults to asc)

Success response (200):

json
{
  "field": "artist",
  "facets": [
    {"value": "周杰伦", "count": 42, "cover_url": "/api/v1/songs/1/cover"}
  ],
  "total": 128,
  "limit": 20,
  "offset": 0
}

total is the total number of distinct values for that dimension.

Errors: 400 Missing or unsupported field | 500 Server error


3. Create, Update, Delete ​

Section source: internal/handlers/music.go, internal/models/models.go

3.1 GET /songs/{id} -- Get Details ​

Path: /api/v1/songs/{id} | Authentication: BearerAuth

Path parameter: id (int, required) -- Song ID

Success response (200): The full Song object.

Errors: 400 Invalid ID | 404 Song does not exist


3.2 PUT /songs/{id} -- Update Song ​

Path: /api/v1/songs/{id} | Authentication: BearerAuth

Path parameter: id (int, required)

Request body:

FieldTypeRequiredDescription
titlestringYesTitle (cannot be empty)
artiststringNoArtist
albumstringNoAlbum
urlstringConditionally requiredPlayback address (required for non-local songs)
cover_urlstringNoCover URL
is_liveboolNoWhether it is a live stream (applies only to the remote type)

Success response (200): The updated Song object.

Errors: 400 Invalid ID / empty title / empty URL | 404 Does not exist | 500 Update failed


3.3 DELETE /songs/{id} -- Delete Song ​

Path: /api/v1/songs/{id} | Authentication: BearerAuth

Path parameter: id (int, required)

Success response (200): {"message": "歌曲已删除"}

Errors: 400 Invalid ID | 500 Delete failed


3.4 POST /songs/remote -- Add Remote Songs ​

Path: /api/v1/songs/remote | Authentication: BearerAuth

Batch-adds network songs. Each entry must provide at least a url or the plugin_entry_path + source_data combination.

Request body (array):

FieldTypeRequiredDescription
urlstringConditionalDirect audio link (for pure external links; leave empty for plugin sources)
titlestringYesTitle
artiststringNoArtist
albumstringNoAlbum
cover_urlstringNoCover URL
durationnumberNoDuration (seconds)
plugin_entry_pathstringConditionalPlugin entryPath (e.g., "subsonic")
source_datastringConditionalPlugin source metadata JSON
dedup_keystringNoDeduplication key (e.g., "nas1:001234"); no deduplication when empty
lyricstringNoLyric content or URL
lyric_sourcestringNoLyric source type

Success response (201): {"songs": [Song...], "count": 2}

Errors: 400 Invalid request / empty list / empty title / missing source identifier | 500 Add failed


3.5 POST /songs/radio -- Add Radio ​

Path: /api/v1/songs/radio | Authentication: BearerAuth

Batch-adds radio/broadcast stations.

Request body (array):

FieldTypeRequiredDescription
urlstringYesRadio stream address
titlestringYesRadio name
artiststringNoDescription
cover_urlstringNoCover URL

Success response (201): {"songs": [Song...], "count": 1}

Errors: 400 Invalid request / empty list / empty URL or title | 500 Add failed


3.6 POST /songs/batch-delete -- Batch Delete ​

Path: /api/v1/songs/batch-delete | Authentication: BearerAuth

Request body:

FieldTypeRequiredDescription
idsint64[]YesList of song IDs to delete
delete_filesboolNoWhether to also delete local audio files (default false)

Success response (200): {"deleted": 3}

Errors: 400 Invalid request / empty ID list | 500 Delete failed


3.7 POST /songs/clean -- Clean Invalid Songs ​

Path: /api/v1/songs/clean | Authentication: BearerAuth

Cleans up records of local songs whose files no longer exist or reside in excluded directories, and deletes the associated cover files.

Success response (200):

json
{"message": "清理完成", "total": 5, "file_not_found": 3, "in_excluded_dir": 2}

Errors: 500 Clean failed


3.8 Remote Song Metadata Refresh ​

For all remote songs with missing metadata, probes duration, bit rate, sample rate, format, and tags via ffprobe and backfills them, executed asynchronously.

POST /songs/refresh-metadata -- Start Refresh ​

Path: /api/v1/songs/refresh-metadata | Authentication: BearerAuth

Success response (202): {"status": "started"}

Errors: 409 Already running | 500 Refresher not configured / start failed

GET /songs/refresh-metadata/progress -- Query Progress ​

Path: /api/v1/songs/refresh-metadata/progress | Authentication: BearerAuth

Success response (200): MetadataRefreshProgress (contains fields such as status; returns {"status": "idle"} when not configured).

POST /songs/refresh-metadata/cancel -- Cancel Refresh ​

Path: /api/v1/songs/refresh-metadata/cancel | Authentication: BearerAuth

Success response: 204 No Content (idempotent; also returns 204 when there is no task).


4. Lyrics and Tags ​

Section source: internal/handlers/music.go, internal/models/lyric.go

4.1 PUT /songs/{id}/lyrics -- Update Lyrics ​

Path: /api/v1/songs/{id}/lyrics | Authentication: BearerAuth

Updates the lyric content and source. The input shape is determined by lyric_source:

  • "url" -- Pass lyric_remote_url, fetched lazily at runtime
  • Others (scraped/file/embedded/cached/manual) -- Pass lyric/tlyric/rlyric/lxlyric

lyric_source=manual marks a user's manual adjustment; a scanner re-scan will not overwrite it.

Path parameter: id (int, required)

Request body:

FieldTypeRequiredDescription
lyric_sourcestringNoSource type
lyricstringConditionalMain lyrics LRC (non-url source)
tlyricstringNoTranslated lyrics
rlyricstringNoRomanized lyrics
lxlyricstringNoWord-by-word lyrics
lyric_remote_urlstringConditionalRemote lyrics URL (url source)

Success response (200): {"message": "歌词已更新", "file_write_status": "written"}

file_write_status: written / unchanged / skipped / failed

Errors: 400 Invalid ID / invalid request | 404 Song does not exist | 500 Update failed


4.2 GET /songs/{id}/lyric -- Get Lyrics ​

Path: /api/v1/songs/{id}/lyric | Authentication: BearerAuth

Returns a LyricPayload JSON. Internally dispatches to database unpacking or remote URL fetching based on lyric_source.

Path parameter: id (int, required)

Success response (200):

json
{"lyric": "[00:00.00]夜曲...", "tlyric": "", "rlyric": "", "lxlyric": ""}

Errors: 400 Invalid ID | 404 Song/lyrics do not exist | 502 Remote fetch failed


4.3 PUT /songs/{id}/tags -- Write Audio Tags ​

Path: /api/v1/songs/{id}/tags | Authentication: BearerAuth

Writes metadata to the database and the local audio file tags (local songs only). Non-empty fields overwrite; empty values are preserved. cover_data (base64) takes precedence over cover_url.

Path parameter: id (int, required)

Request body:

FieldTypeRequiredDescription
titlestringNoTitle
artiststringNoArtist
albumstringNoAlbum
yearintNoYear (overwrites when >0)
genrestringNoGenre
lyricsstringNoLyrics LRC (after writing, lyric_source=manual)
cover_datastringNoCover base64 (takes precedence)
cover_urlstringNoCover URL (written after download)
clear_coverboolNoExplicitly clears the cover

Success response (200): {"song": Song, "file_write": "written"}

file_write: written / unchanged / skipped / failed / unsupported

Errors: 400 Invalid ID / non-local song / invalid base64 | 404 Does not exist | 500 Update failed


5. File Organization ​

Section source: internal/handlers/music.go, internal/services/song_service.go

5.1 POST /songs/organize -- Batch Organize Files ​

Path: /api/v1/songs/organize | Authentication: BearerAuth

Batch-moves/renames local song files. target_path is a path relative to music_path (including directory and file name), and the extension must match the original file.

Request body (array):

FieldTypeRequiredDescription
idint64YesSong ID
target_pathstringYesTarget path (relative to music_path)

Success response (200):

json
[
  {"id": 1, "status": "ok", "file_path": "/music/周杰伦/夜曲.mp3"},
  {"id": 2, "status": "error", "error": "not a local song"}
]

Errors: 400 Invalid request / empty list / music_path not set | 500 Not configured


5.2 POST /songs/organize/preview -- Preview Batch Organization ​

Path: /api/v1/songs/organize/preview | Authentication: BearerAuth

Dry-run preview of directory organization changes; does not move any files or modify the database. Returns each item's old_path→new_path and status. target_path is a path relative to music_path (music_path is obtained by the server itself). CUE songs return skip; a target that already exists or a name collision within the batch returns conflict.

Request body (array): Same as /songs/organize (id + target_path).

Success response (200): An array of OrganizePreviewResult, each item containing status (ok / conflict / skip / error), old_path, and new_path.

Errors: 400 Invalid request / empty list


6. Playback and Media ​

Section source: internal/handlers/music.go

6.1 POST /songs/{id}/activate -- Activate/Prefetch ​

Path: /api/v1/songs/{id}/activate | Authentication: BearerAuth

Called by the client before switching songs. The backend cancels in-progress work for other songs in the same session (prefetch / transcode / reassign), yielding the plugin worker and transcode semaphore to the new song. Idempotent; other client sessions are not affected.

Path parameter: id (int, required)

Success response: 204 No Content

Errors: 400 Invalid song_id


6.2 POST /songs/{id}/played -- Play Event Notification ​

Path: /api/v1/songs/{id}/played | Authentication: BearerAuth

Called by the client when a song starts playing, finishes playing, or is skipped; the backend broadcasts the event to JS plugins that have subscribed to play events (registered via songloft.events.onPlayEvent).

Path parameter: id (int, required)

Query parameters:

ParameterTypeRequiredDescription
sourcestringNoCaller source identifier, e.g., songloft-player (official client), miot (Xiao AI speaker plugin)
typestringNoEvent type: play (start playing) / finish (finished playing, default) / skip (user skipped)
context_typestringNoPlayback context type, only effective when type=play: playlist or a facet dimension (artist/album/genre/year/decade/language/style)
context_keystringNoPlayback context identifier, only effective when type=play: playlist ID for playlist, the dimension value (e.g. artist name) for facets

Side effect: when type=play and a valid context_type + context_key are both supplied, the song is additionally written to that context's play history (deduplicated by song within the context, keeping only the most recent 50 entries). A write failure is only logged and does not affect the status code. See Play History API.

Only type=play is persisted: finish is redundant information about the same song, while skip reports the previous song, whose context may already have switched on the client, filing it under the wrong context.

Success response: 204 No Content

Errors: 400 Invalid song ID / illegal event type | 404 Song does not exist


6.3 GET /songs/{id}/play -- Playback Stream ​

Path: /api/v1/songs/{id}/play | Method: GET / HEAD | Authentication: BearerAuth

Streams audio by song ID, internally dispatching automatically by song.type:

TypeBehavior
localServeFile (supports Range/seek)
remote (plugin)Returned after CacheService download caching
remote (external link)Directly proxied and forwarded
radio (HLS)Reverse proxy or 302 (depending on the hls-proxy switch)
radio (non-HLS)Directly proxies the upstream stream

Path parameter: id (int, required)

Query parameters:

ParameterTypeDescription
formatstringTarget format (e.g., opus); when different, goes through ffmpeg transcoding
prefetchstringWhen "1", prewarms asynchronously and returns 202 immediately

Success: 200 Audio stream | 202 Prefetch triggered | 206 Range partial content | 302 HLS redirect

Errors: 400 Invalid ID | 404 Does not exist | 502 Source unavailable


6.4 GET /songs/{id}/play.m3u8 -- HLS Alias ​

Path: /api/v1/songs/{id}/play.m3u8 | Method: GET / HEAD | Authentication: BearerAuth

An HLS-radio-specific alias that shares the handler with /play. Ending the URL with .m3u8 makes ExoPlayer/AVPlayer correctly select the HlsMediaSource. The client automatically obtains the correct URL via Song.PlaybackURL(). Parameters and responses are the same as 6.3.


6.5 GET /songs/{id}/cover -- Cover ​

Path: /api/v1/songs/{id}/cover | Authentication: BearerAuth

Gets the cover image. Returns the local cover first, and proxies the external CoverURL when it does not exist.

Path parameter: id (int, required)

Success response (200): Image binary, with Content-Type set automatically by extension (jpeg/png/gif/bmp/webp), Cache-Control: public, max-age=31536000.

Errors: 400 Invalid ID | 404 Song/cover does not exist | 500 Read failed


7. HLS Reverse Proxy ​

Section source: internal/handlers/hls.go

The HLS reverse proxy takes effect after the hls_proxy switch is enabled (PUT /settings/hls-proxy). Flow: serveRadio -> ServeProxy fetches the upstream m3u8 and rewrites URIs to local relative paths -> the player calls back playlist/segment to fetch sub-layer resources. Each entry performs a same-origin check + IsHostnameAllowed SSRF protection; non-same-origin URLs are left unchanged.

7.1 GET /songs/{id}/hls/playlist -- Proxy Playlist ​

Path: /api/v1/songs/{id}/hls/playlist | Method: GET / HEAD | Authentication: BearerAuth

Reverse-proxies the HLS sub-layer m3u8. Fetch upstream -> same-origin check -> rewrite URIs -> write back. Upstream errors are passed through; the playlist size limit is 1 MB.

Parameters: id (path, int) | u (query, string, required, base64url-encoded upstream URL) | access_token (query, string, optional)

Success (200): Rewritten m3u8 text, Content-Type: application/vnd.apple.mpegurl, Cache-Control: no-store

Errors: 400 Invalid ID / missing u | 403 Non-same-origin / host not allowed | 404 Song does not exist | 502 Upstream unavailable


7.2 GET /songs/{id}/hls/segment -- Proxy Segment ​

Path: /api/v1/songs/{id}/hls/segment | Method: GET / HEAD | Authentication: BearerAuth

Reverse-proxies audio segments/key/init segments. Passes through Range and upstream response headers.

Parameters: id (path, int) | u (query, string, required, base64url-encoded upstream URL) | access_token (query, string, optional)

Success: 200 Segment content | 206 Range partial content. Cache-Control: no-store

Errors: 400 Invalid ID / missing u | 403 Non-same-origin / host not allowed | 404 Song does not exist | 502 Upstream unavailable